2026_air_la_femme_d_argent.py

#

SPDX-FileCopyrightText: 2026 Zeineb Azzaoui & Charlotte Gigot SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be

SPDX-License-Identifier: GPL-3.0-or-later

#

Written with blender 5.0.1 hash a3db93c5b259

05 06 2026

import math
import random

import bmesh
import bpy
from mathutils import Vector
#

MISE EN PLACE DES FONCTIONS nettoyer la scène

def clean_that_up():
    bpy.ops.object.select_all(action="SELECT")
    bpy.ops.object.delete(use_global=False)
    bpy.ops.outliner.orphans_purge()


clean_that_up()
#

fonction cube de taille 10 pour la base

def _basse(_pos, _size, _name):
    bpy.ops.mesh.primitive_cube_add(size=10, location=_pos, scale=_size)
    bpy.context.object.name = _name
    bpy.context.object.data.name = _name + "_mesh"
#

fonction tubes

def _percu(_pos, _ray, _name):
    bpy.ops.mesh.primitive_cylinder_add(
        radius=_ray, depth=8, location=_pos, scale=(1, 1, 1)
    )
    bpy.context.object.name = _name
    bpy.context.object.data.name = _name + "_mesh"
    obj = bpy.context.object
    obj.rotation_euler = (0, math.radians(90), 0)
#

fonction sphère pour les nappes

def _nappes(_pos, _size, _name):
    bpy.ops.mesh.primitive_uv_sphere_add(radius=1, location=_pos, scale=_size)
    bpy.context.object.name = _name
    bpy.context.object.data.name = _name + "_mesh"
#

focntion modificateur booléen pour creuser

def boolean_boxes(operand_A, operand_B):
    Boolean_boxes = operand_A.modifiers.new(name="my_Boolean", type="BOOLEAN")
    Boolean_boxes.operation = "DIFFERENCE"
    Boolean_boxes.object = operand_B
    Boolean_boxes.solver = "MANIFOLD"  # pour creuser plusieurs formes

    bpy.context.view_layer.objects.active = operand_A
    bpy.ops.object.modifier_apply(modifier=Boolean_boxes.name)
    bpy.data.objects.remove(operand_B, do_unlink=True)
#

fonction cercle pour les plateformes

def _plateforme(_pos, _scale, _name):
#

On crée un cercle avec 32 sommets, rempli (NGON)

    bpy.ops.mesh.primitive_circle_add(
        vertices=32, radius=0.7, fill_type="NGON", location=_pos
    )
    obj = bpy.context.object
    obj.name = _name
    obj.data.name = _name + "_mesh"
#

On applique la scale (en X et Y pour faire un ovale ou un cercle parfait)

    obj.scale = _scale

    return obj
#

Fonction pour créer un matériau à partir d’un code Hexadecimal RGBA

def creer_materiau_hex(nom, hex_rgba):
    mat = bpy.data.materials.new(name=nom)
    mat.use_nodes = True
    nodes = mat.node_tree.nodes
    principled = nodes.get("Principled BSDF")

    if principled:
#

Conversion du format #RRGGBBAA en valeurs pour Blender

        h = hex_rgba.lstrip("#")
        rgba = tuple(int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4, 6))
        principled.inputs["Base Color"].default_value = rgba
    return mat
#

Fonction pour assigner un matériau à toutes les faces d’un objet

def assigner_materiau(obj, mat):
    if len(obj.data.materials) == 0:
        obj.data.materials.append(mat)
    else:
        obj.data.materials[0] = mat
#
#####################CREATION DES ELEMENTS-BASE
#

Créer la base de la musique (basse+nappes puis percu)

#

étape 1 : créer le support = cube de 10*10

_basse((5, 5, 5), (1, 1, 1), "basse")
#

définir la basse comme l’objet à creuser

_obj1 = bpy.data.objects["basse"]
#

attribuer une couleur à la basse

mat_crème = creer_materiau_hex("Mat_crème", "#E7DCC3FF")
assigner_materiau(_obj1, mat_crème)
#

creation des tubes de percussions

for axe_y in range(1, 8):
    for axe_z in range(1, 8):
        nom_percu = f"percu_{axe_y}_{axe_z}"

        pos_y = axe_y * 1.15
        pos_x = 5
        pos_z = axe_z * 1.25
#

pour créer un décalage et pas une grille rigide

        if axe_z % 2 == 1:
            pos_y += 1.0  # Ajuste cette valeur pour modifier l'écart

        _ray = random.uniform(0.1, 0.4)

        _percu((pos_x, pos_y, pos_z), _ray, nom_percu)
#

pour creuser les percu dans le cube

        _obj1 = bpy.data.objects["basse"]
        _obj2 = bpy.data.objects[nom_percu]
        boolean_boxes(_obj1, _obj2)
#

étape 2 : définir une liste de sphères où se plaçeront les plateformes

spheres_valides = []
#

définir une liste de coordonées pour les plateformes

platform_coords = []
#

étape 3 : définir un nombre de sphères aplaties aléatoire entre (X,X) (dépend de la partie du morceau)

nappes_num = random.randint(
    40,
    50,
)  # plus que le corps de la musique car de pluès en plus de clavier
print("Nombre de nappes : f[nappes_num]")
#

créer des nappes de dimensions variables selon un rapport de 1/3 (si i est divisible par 3) 1 sphère sur 3 sera “petite”

for i in range(1, nappes_num):
    if i % 3 == 0:
        scale_xyz = (2, 2, 1)
    else:
        scale_xyz = (2.5, 2.5, 1.5)
#

coordonées aléatoire dans le cube de 10*10 en laissant la possibilité de dépasser

    pos_x = random.uniform(0.5, 9)
    pos_y = random.uniform(0.5, 9)
    pos_z = random.uniform(0, 11)
#

définir la position des sphères selon les coordonées aléatoires en x,y,z

    loc_nappe = (pos_x, pos_y, pos_z)
#

nommer la nappe

    nom_nappe = f"nappe_{i}"
#

création de nappe à chaque boucle

    _nappes(loc_nappe, scale_xyz, nom_nappe)
#

étape 4: définir quel objet creuse lequel

    _obj1 = bpy.data.objects["basse"]
    _obj2 = bpy.data.objects[nom_nappe]
#

chaque sphère creuse le cube à chaque boucle

    boolean_boxes(_obj1, _obj2)
#

informations utiles pour les étapes suivantes en lien avec les nappes

    trop_proche = False
#

distance minimale entre les coordonées des centres en x et y pour les futurs plateformes

    distance_min = 0.5
#

étape 5: chance que les coordonées de la sphère soit gardée pour les plateformes pour que des plateformes ne soient ps trop proches les unes des autres

    for platef in spheres_valides:
        dist_x = loc_nappe[0] - platef["loc"][0]
        dist_y = loc_nappe[1] - platef["loc"][1]
        if math.sqrt(dist_x**2 + dist_y**2) < distance_min:
            trop_proche = True
            break
#

si la distance est suffisante, elle est enregistrée dans les sphères qui peuvent donner une plateforme

    if not trop_proche:
        spheres_valides.append({"loc": loc_nappe, "scale": scale_xyz})

print("Cube creusé")
#

fin de la base de la structure

#
######################CREATION DES ELEMENTS-AJOUT
#

création d’une liste qui stock toutes les futures plateformes

toutes_les_plateformes = []
#

création d’une liste pour stocker les coordonnées des coins des cercles

coordonnees_coins_plateformes = []
#

étape 1: les plateformes/notes de piano

for idx, data in enumerate(spheres_valides):
    nom = f"Plateforme_{idx}"
    loc_origine = data["loc"]  # utilise data['loc'] pour savoir où la placer
    taille_z_sphere = data["scale"][2]  # on récupère la donnée en z des spheres
    decalage_z = taille_z_sphere * -0.5  # on décale vers le bas
#

On calcule la hauteur théorique de la plateforme pour savoir si elle sera en négative

    z_theorique = loc_origine[2] + decalage_z
#

On pose une limite pour que z ne soit jamais inférieur à 0

    z_final = max(z_theorique, 0)
#

Nouvelle position de la plateforme

    loc_decalee = (loc_origine[0], loc_origine[1], z_final)
#

On force des dimensions à 1 (le rayon réel est défini par primitive_circle_add qui est à 0.7)

    rayon_aléatoire = random.uniform(0.5, 1.5)
    _dimension1 = rayon_aléatoire
    _dimension2 = rayon_aléatoire
    _dimension3 = rayon_aléatoire
#

Crée la plateforme ronde

    plat = _plateforme(loc_decalee, (_dimension1, _dimension2, _dimension3), nom)

    toutes_les_plateformes.append(plat)
#

récupère les sommets du cercle de la plateforme

    bpy.context.view_layer.update()

    sommets_globaux = [plat.matrix_world @ v.co for v in plat.data.vertices]
#

On stocke tout avec une liste

    coordonnees_coins_plateformes.append(
        {
            "nom_plateforme": nom,
            "centre": loc_decalee,
            "sommets": sommets_globaux,
            "secteurs_utilises": [],  # Stockera 'N', 'S', 'E', ou 'O'
        },
    )
#

démarage de l’étape pour relier les plateformes entre elles

print("reliage des plateformes en cours")
#

si le lein entre 2 plateformes est trop grand, pas de lien créé

distance_max_pont = 4.5
nb_plateformes = len(coordonnees_coins_plateformes)
#

étape 1 : Les listes pour garder en mémoire les infos

compteur_connexions = [0] * nb_plateformes
#

liste pour retenir les côtés déjà connectés pour chaque plateforme pour pas avoir 2 liens sur un même coté

cotes_utilises = [[] for _ in range(nb_plateformes)]
#

liste qui vérifie la taille des chaînes pour ne pas avoir une grande chaine continue mais plusieurs petites chaines

groupes_connexions = [{k} for k in range(nb_plateformes)]
#

liste tous les ponts

tous_les_ponts = []
#

liste toutes les paires possibles

paires_possibles = []
#

étape 2 : mise en place des conditions CDT° 1: traversabilité des murs On récupère notre objet “basse” (les murs) et on calcule sa matrice inverse.

obj_basse = bpy.data.objects.get("basse")
if obj_basse:
    mat_inv = obj_basse.matrix_world.inverted()
#

on limite l’angle possible pour les ponts entre les plateformes

angle_max_degres = (
    35.0  # Ajuste la valeur de l'angle (35° max par rapport à l'horizontale)
)
#

boucle pour comparer les plateformes entre elles

for i in range(nb_plateformes):
    for j in range(i + 1, nb_plateformes):
        plat_A = coordonnees_coins_plateformes[
            i
        ]  # On additionne les coordonnées de tous les sommets
        plat_B = coordonnees_coins_plateformes[
            j
        ]  # On divise le total par le nombre de sommets (len(...)).
#

Le résultat donne un point qui représente le centre exact de la plateforme.

        centre_A = sum(plat_A["sommets"], Vector((0, 0, 0))) / len(plat_A["sommets"])
        centre_B = sum(plat_B["sommets"], Vector((0, 0, 0))) / len(plat_B["sommets"])

        distance = (centre_A - centre_B).length

        if distance < distance_max_pont:
#
  1. On calcule la distance horizontale pure (uniquement en X et Y, comme vu du dessus)
            dist_horizontale = math.sqrt(
                (centre_A.x - centre_B.x) ** 2 + (centre_A.y - centre_B.y) ** 2,
            )
            delta_z = abs(centre_A.z - centre_B.z)  # Différence de hauteur
#

Si les cercles sont trop proches ou superposés vus du dessus, pont trop vertical donc pas à créer Comme le rayon est de 0.7, le diamètre combiné est de 1.4. On met une limite de sécurité à 1.8.

            if dist_horizontale < 1.8:
                continue  # Rejet si les plateformes sont trop l'une sur l'autre
#
  1. Sécurité de pente classique
            pente_max_toleree = math.tan(math.radians(angle_max_degres))
            if (
                dist_horizontale == 0
                or (delta_z / dist_horizontale) > pente_max_toleree
            ):
                continue  # Rejet si la pente dépasse l'angle max
#
  1. Sécurité pour éviter les ponts trop verticaux (piliers/murs) Si les plateformes sont presque l’une sur l’autre mais à des hauteurs différentes
            if dist_horizontale < 0.3 and delta_z > 0.2:
                continue  # On rejette immédiatement, c'est un pilier vertical !
#
  1. Calcul de l’angle réel par rapport à l’horizontale via l’arc tangente (hauteur / base)
            if dist_horizontale > 0:
                angle_radians = math.atan(delta_z / dist_horizontale)
                angle_degres = math.degrees(angle_radians)
            else:
                angle_degres = 90.0  # Vertical pur
#

Si la pente est supérieure à l’angle max, on recommence

            if angle_degres > angle_max_degres:
                continue

            print("fin du calcul d'angle")
#

TEST RAYCAST pour verifier la traversabilité dans le cube des paroies (comme un laser qui traverse les paroies)

            offset = Vector((0, 0, 0.2))
            origin_ray = centre_A + offset
            dest_ray = centre_B + offset
#

On convertit ces points globaux en coordonnées “locales” pour l’objet basse

            origin_local = mat_inv @ origin_ray
            dest_local = mat_inv @ dest_ray
            direction_local = (dest_local - origin_local).normalized()
            distance_locale = (dest_local - origin_local).length
#

test ray_cast

            hit, loc, normal, face_idx = obj_basse.ray_cast(
                origin_local,
                direction_local,
                distance=distance_locale,
            )
#

Si hit est False, le laser n’a rien touché, le ponts peut se former sans toucher les mur.

            if not hit:
                paires_possibles.append((i, j, distance, centre_A, centre_B))
#

On trie par distance

paires_possibles.sort(key=lambda x: x[2])
#

On crée les ponts avec les nouvelles conditions de secteurs et de pente

for paire in paires_possibles:
    i, j, distance, centre_A, centre_B = paire
#

CDT° 2 : Taille de la chaîne

    groupe_i = next(g for g in groupes_connexions if i in g)
    groupe_j = next(g for g in groupes_connexions if j in g)

    if groupe_i != groupe_j:
        if (
            len(groupe_i) + len(groupe_j) > 3
        ):  # les chaines sont limitées à 3 plateformes max
            continue
#

CDT° 3 : Maximum 2 ponts par plateforme globale

    if compteur_connexions[i] < 2 and compteur_connexions[j] < 2:
        plat_A_data = coordonnees_coins_plateformes[i]
        plat_B_data = coordonnees_coins_plateformes[j]

        dist_horizontale = math.sqrt(
            (centre_A.x - centre_B.x) ** 2 + (centre_A.y - centre_B.y) ** 2,
        )
        delta_z = abs(centre_A.z - centre_B.z)
#

Si la pente (Hauteur / Distance au sol) est supérieure à la limite de l’angle max

        pente_max_toleree = math.tan(math.radians(angle_max_degres))

        if dist_horizontale == 0 or (delta_z / dist_horizontale) > pente_max_toleree:
            continue  # Rejet immédiat si c'est trop raide ou vertical pur
#

On calcule l’angle directionnel du pont sur le plan horizontal X/Y

        vecteur_AB = (centre_B - centre_A).normalized()
        angle_AB = math.atan2(
            vecteur_AB.y, vecteur_AB.x
        )  # Angle en radians de A vers B
        angle_BA = math.atan2(-vecteur_AB.y, -vecteur_AB.x)  # Angle de B vers A
#

Fonction interne pour attribuer un secteur (N, S, E, O) selon l’angle

        def determiner_secteur(angle):
            degres = math.degrees(angle) % 360
            if 45 <= degres < 135:
                return "N"
            if 135 <= degres < 225:
                return "O"
            if 225 <= degres < 315:
                return "S"
            return "E"

        secteur_A = determiner_secteur(angle_AB)
        secteur_B = determiner_secteur(angle_BA)
#

CDT° 4 : Vérification si le côté/secteur est déjà pris

        if (
            secteur_A in plat_A_data["secteurs_utilises"]
            or secteur_B in plat_B_data["secteurs_utilises"]
        ):
            continue  # Ce côté de la plateforme est déjà occupé par un autre pont !
#

création du maillage pour les ponts

        nb_sommets_liaison = 6  # Le pont sera attaché à chaque plateforme en utilisant exactement 6 sommets
        axe_pont = vecteur_AB
        axe_perpendiculaire = Vector((-axe_pont.y, axe_pont.x, 0))
#

Le code trie tous les sommets de la plateforme A selon leur distance par rapport au centre de la plateforme B il ne garde que les 6 sommets les plus proches de B

        sommets_A_proches = sorted(
            plat_A_data["sommets"],
            key=lambda v: (v - centre_B).length,
        )[:nb_sommets_liaison]
        sommets_A_proches.sort(key=lambda v: v.dot(axe_perpendiculaire))
#

pareil pour la plateforme B

        sommets_B_proches = sorted(
            plat_B_data["sommets"],
            key=lambda v: (v - centre_A).length,
        )[:nb_sommets_liaison]
        sommets_B_proches.sort(key=lambda v: v.dot(axe_perpendiculaire))
#

crée un nouveau maillage

        nom_pont = f"Pont_{i}_vers_{j}"
        mesh_pont = bpy.data.meshes.new(nom_pont)
        obj_pont = bpy.data.objects.new(nom_pont, mesh_pont)
        bpy.context.collection.objects.link(obj_pont)

        verts_pydata = [(v.x, v.y, v.z) for v in sommets_A_proches] + [
            (v.x, v.y, v.z) for v in sommets_B_proches
        ]
#

La boucle va créer 5 faces quadrilatères (rectangles) pour relier les sommets en face à face

        faces_pydata = []
        for k in range(nb_sommets_liaison - 1):
            v0 = k
            v1 = k + 1
            v2 = nb_sommets_liaison + k + 1
            v3 = nb_sommets_liaison + k
            faces_pydata.append((v0, v1, v2, v3))

        mesh_pont.from_pydata(verts_pydata, [], faces_pydata)
        mesh_pont.update()

        tous_les_ponts.append(obj_pont)
#

crée des compteurs pour calculer le nombre de connexions

        plat_A_data["secteurs_utilises"].append(secteur_A)
        plat_B_data["secteurs_utilises"].append(secteur_B)

        compteur_connexions[i] += 1
        compteur_connexions[j] += 1

        if groupe_i != groupe_j:
            groupe_i.update(groupe_j)
            groupes_connexions.remove(groupe_j)
#
##NETTOYAGE et SUPPRESSION DES PLATEFORMES ISOLÉES
print("Nettoyage des plateformes isolées...")
#

On crée une copie de la liste pour éviter les bugs pendant qu’on supprime des éléments

plateformes_a_garder = []

for idx, plat in enumerate(toutes_les_plateformes):
#

Si la plateforme n’a aucun pont (compteur à 0)

    if compteur_connexions[idx] == 0:
        nom_plat = plat.name
        mesh_plat = plat.data
#
  1. On retire l’objet de la scène
        bpy.data.objects.remove(plat, do_unlink=True)
#
  1. On détruit son maillage pour laisser Blender propre
        if mesh_plat:
            bpy.data.meshes.remove(mesh_plat)

        print(f"[{nom_plat}] supprimée (isolée)")
    else:
#

Si elle est connectée, on la garde dans la liste pour la fusion finale

        plateformes_a_garder.append(plat)
#

On met à jour la liste globale avec uniquement les plateformes valides

toutes_les_plateformes = plateformes_a_garder
#
#FUSION + COLORATION + ÉPAISSEUR
print("Attribution des couleurs par chaînes entières...")
#
  1. Création des deux matériaux nécessaires
mat_or = creer_materiau_hex(
    "Mat_Or",
    "#F4DC73FF",
)  # couleur qui avait été choisie dans la vue 3D qui est reprise en language de base16
mat_turquoise = creer_materiau_hex("Mat_Turquoise", "#ABECF4FF")
#
  1. On filtre les groupes pour ne garder que les plateformes qui ont survécu au nettoyage
groupes_actifs = []
for groupe in groupes_connexions:
    groupe_filtre = {idx for idx in groupe if compteur_connexions[idx] > 0}
    if groupe_filtre:  # Si le groupe n'est pas vide
        groupes_actifs.append(groupe_filtre)
#
  1. On applique les couleurs 50/50 sur les chaînes, la moitié des plateformes seront d’une couleur etc
total_groupes = len(groupes_actifs)

for g_idx, groupe in enumerate(groupes_actifs):
#

Choix de la couleur pour toute la chaîne

    mat_du_groupe = mat_or if g_idx < (total_groupes / 2) else mat_turquoise

    for plat_idx in groupe:
#

Couleur pour la plateforme

        nom_plat = f"Plateforme_{plat_idx}"
        plat_obj = bpy.data.objects.get(nom_plat)
        if plat_obj:
            assigner_materiau(plat_obj, mat_du_groupe)
#

Colorer le pont associé à cette plateforme On cherche si un pont impliquant cet index existe

        for j in range(nb_plateformes):
            nom_pont_1 = f"Pont_{plat_idx}_vers_{j}"
            nom_pont_2 = f"Pont_{j}_vers_{plat_idx}"

            pont_obj = bpy.data.objects.get(nom_pont_1) or bpy.data.objects.get(
                nom_pont_2
            )
            if pont_obj:
                assigner_materiau(pont_obj, mat_du_groupe)
#
  1. Désélection générale avant fusion
bpy.ops.object.select_all(action="DESELECT")
#
  1. Sélection de tous les éléments restants pour les joindre
elements_a_fusionner = toutes_les_plateformes + tous_les_ponts
for obj in elements_a_fusionner:
    if obj.name in bpy.context.scene.objects:  # pour vérifier les objets sélectionnés
        obj.select_set(True)
#
  1. Fusion
if len(toutes_les_plateformes) > 0:
    obj_global = toutes_les_plateformes[0]
    bpy.context.view_layer.objects.active = obj_global
    bpy.ops.object.join()

    bpy.ops.object.mode_set(mode="OBJECT")
#

Nettoyage BMesh

    bm = bmesh.new()
    bm.from_mesh(obj_global.data)
    bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.01)
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    bmesh.ops.triangulate(bm, faces=bm.faces)
    bm.to_mesh(obj_global.data)
    bm.free()
    obj_global.data.update()

    obj_global.name = "Structure_Plateformes"
    obj_global.data.name = "Structure_Plateformes_mesh"
#

Épaisseur Solidify, les chaines étaient encore plates, il faut leur donner de l’épaisseur

    mod_solid_global = obj_global.modifiers.new(
        name="Epaisseur_Globale", type="SOLIDIFY"
    )
    mod_solid_global.thickness = 0.15  # épaisseur
    mod_solid_global.offset = -1
    bpy.ops.object.modifier_apply(modifier=mod_solid_global.name)
#
  1. lisser l’ensemble
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.shade_auto_smooth()
bpy.ops.object.select_all(action="DESELECT")


print("Fini avec succès !")